iT邦幫忙

2026 iThome 鐵人賽

DAY 21
0
佛心分享-SideProject30

30 天開發一款真正能每天使用的散步 App系列 第 21

離線模式(2)——安全同步本地資料

  • 分享至 

  • xImage
  •  

昨天處理了 SQLite 在離線模式中的角色:散步進行期間,散步紀錄、GPS、暫停紀錄與導航狀態都先保存在本地,完成同步後才清除暫存資料。

但資料存進 SQLite,只能保證 App 關閉後還能找回來,真正困難的是恢復網路後,如何把本地資料安全送到後端。

尤其 GPS 不是只有幾筆資料,一場散步可能累積數百甚至上千個點。只要同步過程中漏掉一批,最後的距離、速度與推薦路線完成率都可能不正確。

因此今天要處理的問題是:如何確認資料真的已經同步到後端,以及什麼時候才能刪除 SQLite 中的暫存資料。

先有散步紀錄,才能同步 GPS

每一筆 GPS 都需要對應到一場散步,因此在上傳 GPS 前,後端必須先有相同的散步紀錄。

為了讓離線建立的散步也能順利同步,我不會等後端建立資料後才取得 ID,而是在 App 建立散步時,就先產生 UUID:

const walk: LocalWalk = {
  id: crypto.randomUUID(),
  status: 'active',
  startedAt: new Date().toISOString(),
};

await walkRepository.insert(walk);

之後同步到後端時,直接沿用同一個 walk.id

這樣本地的 walk_points.walkId 不需要在同步後重新替換,後端也能直接使用相同 ID 建立關聯。

但這裡還有一個問題:假設 App 已經成功把散步資料送到後端,後端也完成寫入,但 Response 回到 App 之前網路突然中斷。App 並不知道後端到底有沒有成功,因此下一次同步時,很可能會再次送出相同的請求。

所以後端不能單純把第二次請求視為錯誤,而是要以 id 判斷這筆散步是否已經存在。

例如:

const existingWalk = await walkRepository.findById(input.id);

if (existingWalk) {
  return existingWalk;
}

return walkRepository.create(input);

相同 ID 重複送出時,最後仍然只會得到同一筆散步資料。

也就是說同步請求可以重複,但結果不能重複。

GPS 不能逐筆上傳

如果每收到一個 GPS 就立即呼叫 API,一場散步下來可能會產生大量 HTTP Request。

GPS 仍然先完整寫入 SQLite,需要同步時,再從 SQLite 取出一批尚未同步的資料,一次送到後端:

const points = await walkPointRepository.findPending({
  walkId,
  limit: 100,
});

每個 GPS 都有自己的 sequence

type WalkPoint = {
  walkId: string;
  sequence: number;
  latitude: number;
  longitude: number;
  recordedAt: string;
};

sequence 不只是排序用途,也能用來穩定識別同一場散步中的 GPS。

同一個 walkId 下,每個 sequence 都必須唯一,因此後端資料庫需要對這兩個欄位建立唯一限制:

CREATE UNIQUE INDEX idx_walk_points_walk_sequence
ON walk_points (walk_id, sequence);

這樣即使同一批 GPS 因為網路問題被送出兩次,也不會產生重複資料。

例如:

await db
  .insert(walkPoints)
  .values(input.points)
  .onConflictDoNothing({
    target: [
      walkPoints.walkId,
      walkPoints.sequence,
    ],
  });

這裡的重點不是阻止 App 重送,而是讓重送不會破壞後端資料。

為什麼不能送出後就刪除?

最危險的做法是發出 Request 後就直接刪除本地 GPS:

// 錯誤
await api.post('/walk-points/batch', points);
await walkPointRepository.delete(points);

因為「Request 已經送出」不代表 App 已經能確認後端成功保存。

例如:

App
 ↓
POST GPS Batch
 ↓
後端寫入成功
 ↓
後端準備回傳 Response
 ↓
網路中斷
 ↓
App 沒有收到 Response

這時候後端可能已經成功保存資料,但 App 並不知道。

如果 App 因為沒有收到 Response,就直接把 SQLite 中的資料刪掉,之後就失去了重新同步的機會。

反過來,如果 App 為了安全而不刪除,下一次同步又會再次送出同一批資料。

所以真正需要解決的不是「如何避免重送」,而是:即使資料被重送,也不能造成錯誤,同時 App 還要知道哪些資料已經可以安全刪除。

前面的 walkId + sequence 唯一限制解決了第一個問題,接下來還需要解決第二個問題。

用 ACK 確認哪些 GPS 已經保存

批次上傳完成後,後端需要明確回傳目前可以確認的 GPS 範圍:

{
  "walkId": "walk-id",
  "acknowledgedThroughSequence": 199
}

這裡的 acknowledgedThroughSequence 不是「後端目前收到的最大 Sequence」。

它代表的是從第一筆開始,到這個 Sequence 為止,都已經完整保存,中間沒有缺。

例如後端目前有 0~99 和 200~299,即使 299 已經存在,也只能確認到 acknowledgedThroughSequence = 99,因為 100~199 還沒有保存。

只有中間的資料補齊後,才能繼續往後確認。App 也只會刪除後端明確確認的範圍。

因此即使同步途中失敗,尚未被確認的 GPS 仍然會留在 SQLite。

下一次同步時可以再次送出。

為什麼不能只看最後一筆 GPS?

如果只檢查後端是不是已經存在 sequence = 299,很容易誤以為這場散步的 GPS 已經全部同步。

但實際上中間還可能存在缺口,因此同步進度不能只看「最大的 Sequence」。

必須確認從第一筆開始,資料是否連續存在。

這也是 acknowledgedThroughSequence 存在的原因。

只要中間還有缺口,就不能往後確認。

按下結束,不代表後端已經完成散步

這其實很重要的一點。

使用者按下「結束散步」時,即使目前沒有網路,也應該能正常結束操作。

但這不代表後端現在就能把散步標記為 completed,因為此時可能還有 GPS、Pause 等資料尚未同步。

所以我把使用者結束散步和後端正式完成散步拆成兩個不同階段:

使用者操作上的「已結束」
        ↓
     等待同步
        ↓
   後端正式完成

按下結束後,App 可以先將本地 walk.status 設為 pending_sync,並保存:

{
  endedAt,
  status: 'pending_sync',
}

這樣使用者不需要等待網路,可以直接離開散步頁面。

等網路恢復後,再由同步流程處理剩下的工作。

最後的本地整理交給 Transaction

完成同步後,本地需要同時處理兩件事情:

  1. 保存後端回傳的正式摘要
  2. 清除這場散步的暫存資料

因此這個階段可以放在同一個 SQLite Transaction:

await database.transaction(async tx => {
  await localWalkHistoryRepository.upsert(
    tx,
    completedWalk,
  );

  await walkPointRepository.deleteByWalkId(
    tx,
    walkId,
  );

  await walkPauseRepository.deleteByWalkId(
    tx,
    walkId,
  );

  await navigationStateRepository.deleteByWalkId(
    tx,
    walkId,
  );

  await walkRepository.delete(tx, walkId);
});

這裡的 Transaction 只負責本地資料的一致性

後端 API 已經在前面的流程完成,SQLite Transaction 則負責確保:正式摘要保存 + 暫存資料清除

這兩個本地操作要嘛全部成功,要嘛全部rollback。

如果 App 剛好在 Transaction 執行期間發生錯誤,就不會出現歷史摘要沒有保存但 GPS 已經被刪掉,或歷史摘要已保存但只刪掉一部分暫存資料的狀況。

小結

做到這裡,離線同步的基本流程就建立起來了。

SQLite 負責保存散步進行期間的本地資料;網路恢復後,再透過同步流程將資料送到後端。

其中:

  • Walk 使用 UUID,讓離線建立的資料可以直接同步
  • GPS 使用 walkId + sequence 保證資料唯一
  • Batch Upload 減少大量 GPS 造成的 API Request
  • Idempotency 讓同步失敗後可以安全重送
  • acknowledgedThroughSequence 確認哪些 GPS 已經完整保存
  • pending_sync 讓使用者可以先結束散步,再等待後續同步
  • 後端完成正式統計後,才保存歷史摘要
  • SQLite Transaction 確保本地摘要與暫存資料的整理保持一致

不過目前這樣設計還有一個問題:如果同步到一半 API 失敗、App 被關閉,或者網路在同步過程中反覆斷線,系統還需要知道「哪些同步工作還沒完成,以及什麼時候應該再次執行。」

因此明天會開始處理 SyncWorker 與 Retry Queue,讓同步工作可以在失敗後自動恢復,而不是每次都從頭開始。


上一篇
離線模式(1)——SQLite 解決不了真正的離線問題
下一篇
離線模式(3)——建立可恢復的同步機制
系列文
30 天開發一款真正能每天使用的散步 App22
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言